Skip to content

fix(tabs): grouped rail interaction fixes for inline rename - #526

Open
aakhter wants to merge 4 commits into
Ark0N:masterfrom
aakhter:pr/grouped-rail-rename-fixes
Open

aakhter wants to merge 4 commits into
Ark0N:masterfrom
aakhter:pr/grouped-rail-rename-fixes

Conversation

@aakhter

@aakhter aakhter commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #525 (#517 and #519 are now in master). Only the last two commits are new: the fix and its review follow-up.

This fixes two problems with renaming a tab inline in the vertical rail. Both are easier to hit now that the grouped rail has its own group editor next to the session one.

Rename writes. A committed rename only applied the server's answer if the same editor was still open when the PUT returned. Reopening the editor before then (F2 or right-click again, or starting a group rename, which cancels the session editor) threw the confirmed name away. The tab showed the old name until an SSE frame happened to repaint it, and two quick renames raced as two concurrent PUTs.

Inline renames now go through a per-session queue:

  • One PUT at a time, in the order the renames were made.
  • The confirmed name is applied to app.sessions whatever happened to the editor.
  • The "already that name" check runs when the write runs, so confirming the name still on screen while an earlier write is in flight is a real write.

Editor layout. The editor could not shrink below the input's intrinsic width. A long w<n>-<case> prefix pushed the label past its row: the prefix slid out of view in the detailed rows, and the input was clipped mid-word in the compact rail. Now:

  • The prefix gives way first, down to 2rem with an ellipsis.
  • The input keeps 4rem.
  • In the compact rail, the row's badges and actions step aside while the name is edited.

The detailed rows' three-line clamp also outranked the shared unclamp rule, which is why the existing "unclamped editor ... restores clamp on cancel" browser test failed on master. That rule is now restated for the detailed rows, and the test passes.

The layout fix applies to both vertical layouts, the tab rail and the session sidebar, since they share these rules: the input's 4rem floor is the editor's own inline min-width, chosen per layout (0 in the header strip, 4rem in the rail and sidebar). The sidebar's markup is unchanged but its editor gets the same fix, and the long-prefix test covers the simple, detailed and compact rail plus the sidebar and the detailed sidebar. The header strip is pixel-identical, and there are no server changes. Tests are in test/inline-rename.test.ts (browser suite, already in BROWSER_TEST_GLOBS): the vertical test now runs for both simple and detailed rows, plus new write-ordering and long-prefix cases. 8 of its 22 tests fail on the parent commit.

Touch drag in the grouped rail is still not included. Upstream has no drag handle to hang it on, so it would need a new handle design first.

@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Same situation as #525: GitHub only lets me open this as a draft right now and won't let me mark it ready, so it's a draft for that reason only. It's ready for review (stacked on #525; only the last commit is new).

@Ark0N

Ark0N commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks for this, @aakhter. This moves the inline rename write out of the editor into a small per-session queue, so a confirmed name survives a reopened or cancelled editor and two quick renames can no longer race. It also fixes the rail editor's layout when a tab has a long w<n>-<case> prefix. The layout half is a clear win. I measured the editor with a long prefix in every list layout. On master, the compact rail pushes the label 179px off the left edge. The detailed rail keeps its three-line clamp, and its input covers the close button. The sidebar leaves the input 10px wide on top of the gear and close buttons. With this commit every one of them keeps the input at 64px or more inside the row, and the header strip is pixel-identical.

Should fix

  1. Reopening the editor while a rename is in flight shows the old name, and dismissing it undoes the rename (src/web/public/session-ui.js:2902, :2923 and :2957; asserted by test/inline-rename.test.ts:805). Rename "Old" to "First", press Enter, then reopen the editor (F2 or right-click) before the PUT answers. Cancelling the first editor puts its old label back (:2957), and the reopened editor fills itself from session.name, which is still "Old". Blur commits, so clicking away queues "Old" behind "First" and the server ends on "Old". I reproduced it with held PUTs: the requests were ["First", "Old"] and the final name was "Old". On the parent commit the same steps send only "First", so the server keeps the rename. The name on screen in that window is out of date by construction, so it isn't really the user's last word. Suggest tracking the newest queued name per session (for example _inlineRenamePending, set in _queueInlineSessionName and cleared with the queue entry), and filling the reopened editor's prefix, input and fullName === ... comparison from pending ?? session.name. Then an untouched confirm queues "First", the queue sees it has already landed and sends nothing, and a real new name still goes out in order. The test at :805 would then expect bodies: ["First"] and mapName: "First".

  2. A failed write is silent once its editor is gone (src/web/public/session-ui.js, lines 2987 to 2990). A confirmed name is now applied whatever happened to the editor, but the "Failed to rename" toast still only fires while that editor is current. Rename, then reopen and press Escape (or start a group rename), and let the PUT fail: no toast, the tab shows the old name, and the user can't tell whether the rename failed or is still pending. I reproduced it with a held PUT answered with a 500. With the editor still open, the toast fires as expected. Showing the toast from the queue's confirmed === null branch, and keeping only restoreOriginalChildren() in the editor, makes success and failure behave the same way.

Small ones

  1. The queue can still reject, and one rejection blocks that session's renames until reload (src/web/public/session-ui.js:2586, :2591 and :2608). The doc comment says "never rejects", but only the PUT is inside the try. If anything after it throws (_applyLocalSessionName calls updateSubagentParentNames), the task rejects. The cleanup .then at :2608 is then skipped, so the map entry stays, and every later task (chained with .then(onFulfilled) only) rejects without sending. With a forced throw in updateSubagentParentNames, the next rename was never sent and the entry stayed in _inlineRenameWrites; the parent commit still sent it. To fix: chain from prev.catch(() => {}), wrap the work after the PUT in a try/catch, and clean up with task.then(cleanup, cleanup).

  2. min-width: 4rem !important overrides an inline style the same function writes (src/web/public/styles.css:1841, src/web/public/session-ui.js:2929). startInlineRename already picks renameWidth per layout, so it can pick the inline min-width the same way (0 for the header strip, 4rem for the rail and sidebar), and the stylesheet can drop the !important.

  3. Mention in the description that the sidebar editor changes too, and add a test for it. The rules you edited apply to both the rail and the sidebar, so the sidebar gets the same fix (described above). "The sidebar's markup is unchanged" is accurate, but it reads as "the sidebar is unaffected". Adding sessionListLayout: 'sidebar' and 'sidebar-rich' cases to the long-prefix it.each would keep it from regressing.

Landing

This lands after #525: the group rename test calls startTabGroupRename, which only #525 adds. The stack also needs the same rebase onto master now that #517 and #519 shipped in 1.34.0. I cherry-picked just this commit onto current master and it applies cleanly. On that tree, 21 of the 22 inline rename tests pass, and the one failure is the #525 test (app.startTabGroupRename is not a function). None of master's 1.34.0 changes to session-ui.js or styles.css touch the rename code or these selectors.

What I verified locally

  • npm run test:browser -- test/inline-rename.test.ts: 22 of 22 pass at this commit, in two runs. With this test file on the parent commit 2fcbf2fc, 8 of 22 fail, each on a real assertion, so the claim in the description holds.
  • On master, master's own copy of the clamp test fails (expected '3' to be 'none'). With this commit, both the simple and detailed variants pass.
  • The queue with held PUTs: renames go out one at a time and in order. A failure in the middle doesn't stop the next write. A session closed mid-queue sends nothing more and leaves no queue entry behind. Confirming an unchanged name with nothing in flight sends no PUT, so it can't flip an auto-named tab to manual.
  • Layout measurements in the compact rail (default and og skins), the detailed rail, the sidebar, the detailed sidebar and the header strip. In the compact rail, the badges and actions hide only while editing and come back after both Escape and Enter. The close button stays clickable in every layout where it is shown, and on master the detailed rail's input covered it.
  • test/session-auto-name.test.ts, test/routes/session-name-routes.test.ts, the static rail, sidebar and tab layout suites, and the rail and sidebar browser suites all pass. check:frontend-syntax and a PostCSS parse of styles.css are clean.

The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.

Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
  reorderGroup, moveRef) applied to the rail at once, mirroring the server
  model: a moved session takes the sessions that still follow it, and a
  hand-moved child is marked placement 'manual'. normalizeLayout now keeps
  placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
  made in the same turn share a write; edits made while one is in flight go
  out on the version it returns. A 409 replays the operations onto the
  layout the server returned and retries (bounded); an operation that no
  longer applies is dropped and reported. A 400 re-reads first; any other
  failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
  that changes nothing.

Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
  Ungrouped and Move to new group in the vertical rail. Before the first
  group exists it offers only "Move to new group", which is how a flat rail
  becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
  a hover glyph (a non-focusable aria-hidden span, so the treeitem still
  holds no interactive child): Rename, New group, Move group up/down,
  Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
  plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
  back to its row or header), a pointer outside, Tab, focus leaving it, a
  resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
  the current editor releases the render guard. Enter or blur commits,
  Escape cancels, IME composition keys are left to the IME, and the label
  becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
  row or into a group, a header drag reorders groups. Escape cancels; the
  click that ends a drag neither selects nor toggles. The flat rail and the
  header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
  once it settles; a read otherwise rebases unsaved edits. On pagehide,
  unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
  replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.

Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.

Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
- Pointer drag: a press released outside the rail no longer lingers. The
  release is heard on window while a press is pending, a move with the
  primary button up cancels it, a new press cancels any previous drag, and
  an existing Escape listener is removed before another is added, so no
  orphaned capture listener can swallow Escape before the terminal.
- Inline group rename: a commit by blur leaves focus where the user put it;
  Enter and Escape still return focus to the header.
- A failed layout read while edits are pending keeps the held layout and the
  editor and re-reads once the write settles, so a 409 is still rebased.
  Dropping unsaved work now always says so in a toast.
- "Move to <group>" quotes the group name (with a matching zh-CN pattern), so
  a group named "New group" or "ungrouped" no longer reads or translates like
  the fixed entries.
- The group menu glyph stays visible under (hover: none).
- The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is
  ignored for another owner, after 60 s, or against an older layout. A move
  with no anchor carries no index, so a replay keeps the row last.
- A 400 that survives the re-read is reported as "Could not save tab groups."
- closeTabRailActionMenu() no longer removes the group menu's DOM.
- Cancelling "Delete group" returns focus to the header.
- Stale comments updated.
Two problems with renaming a tab in the vertical rail, both easier to hit now
that the grouped rail has its own inline editor beside the session one.

Writes. A committed rename PUT its name and only applied the answer if the
same editor was still open when it came back. Reopening the editor before the
PUT answered (F2 or right-click again, or starting a group rename, which
cancels the session editor) threw the confirmed name away, so the tab kept
showing the old name until an SSE frame happened to repaint it. Two quick
renames also raced as two concurrent PUTs. Inline renames now go through a
per-session queue: one PUT at a time in the order they were made, the
confirmed name applied to app.sessions whatever happened to the editor, and
the "already that name" check made when the write runs rather than when Enter
is pressed, so confirming the name still on screen over a write in flight is
a real write.

Layout. The editor (a flex row) could not shrink below the input's intrinsic
width, so a long w<n>-<case> prefix pushed the label past its row: the prefix
slid out of view in the detailed rows and the input was clipped mid-word in
the compact rail. The label now has min-width 0, the prefix gives way first
(down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact
rail the row's adornments step aside while the name is edited. The detailed
rows' three-line clamp also outranked the shared unclamp rule, which is what
the existing "unclamped editor" browser test caught; it is restated there.

Header strip, sidebar and flat-rail markup are unchanged.

Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for
simple and detailed rows; a write-ordering describe covers ordering, a
reopened editor cancelled over a confirmed write, a re-sent unchanged name and
a group rename taking over; a long-prefix describe drives real rows from a
live session in simple, detailed and compact rails.
Reopening the editor over a rename still in flight filled it from the name
the server had not replaced yet, so dismissing it (blur commits) queued the
old name behind the new one and undid the rename. The queue now records the
newest queued name per session (_inlineRenamePending, cleared with the queue
entry), and a reopened editor takes its prefix, input and "unchanged"
comparison from it. An untouched confirm sends nothing more.

A failed write only toasted while its editor was still current. The queue
reports the failure itself now, and the editor only puts its label back.

One rejected task blocked every later rename of that session until reload.
Each task now chains from a settled predecessor, the local apply after a
successful PUT is guarded, and the queue entry is cleaned up on either
outcome.

The rail and sidebar editor's 4rem floor moves from a stylesheet
`!important` into the inline min-width startInlineRename already writes per
layout (0 in the header strip, 4rem in the rail and sidebar).

Tests: the reopened-editor case now expects only "First" to be sent; new
cases cover a 500 answered after the editor is gone and a throw in
updateSubagentParentNames; the long-prefix check runs in the sidebar and
detailed sidebar too and asserts the inline floor; the header strip editor
keeps min-width 0.
@aakhter

aakhter commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, and for reproducing each one with held PUTs; that made them easy to pin down. All five are addressed in a follow-up commit on this branch:

  1. Reopened editor. The queue now tracks the newest queued name per session (_inlineRenamePending, cleared together with the queue entry). A reopened editor fills its prefix, its input and the unchanged-name comparison from pending ?? session.name. The test you pointed at now expects bodies: ["First"] and mapName: "First", and also checks that the reopened input shows "First".
  2. Silent failure. The "Failed to rename" toast now fires from the queue's confirmed === null branch, and the editor only restores its label. A new test answers a held PUT with a 500 after the editor was reopened and dismissed, and expects the toast.
  3. Rejection. Each task now chains from prev.catch(() => {}), the work after the PUT is wrapped in a try/catch, and cleanup runs via task.then(cleanup, cleanup). A new test forces a throw in updateSubagentParentNames and checks that the next rename is still sent and no queue entry is left behind.
  4. !important. Gone. startInlineRename sets the inline min-width per layout (0 for the header strip, 4rem for the rail and sidebar), and tests assert both.
  5. Sidebar. The description now says the sidebar editor gets the same fix, and the long-prefix it.each has sidebar and sidebar-rich cases.

Each new or changed test failed before its fix, and test/inline-rename.test.ts passes 27 of 27 across two runs. On landing: as you said, this depends on #525's startTabGroupRename, so it is rebased onto the updated #525, which is itself rebased onto master.

@aakhter
aakhter force-pushed the pr/grouped-rail-rename-fixes branch from 4b50d52 to 243797e Compare October 5, 2026 00:25
@Ark0N

Ark0N commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Thanks @aakhter. I checked 243797e against all five items and everything is in. The rebased a61bbbf is patch-identical to the commit I reviewed (git range-diff).

What I checked

  • Live with held PUTs (isolated instance, a throwaway shell session, page.route holding PUT /api/sessions/*/name). Old to First with Enter, then reopened before the PUT answered: the input showed "First", clicking away sent only ["First"], and the server and the tab both ended on "First". Then First to Second held, reopened, Escape, PUT answered 500: exactly one "Failed to rename" toast, label and server stayed on "First", and a later reopen prefills "First", not the failed "Second". With the editor still open when the PUT fails, it's one toast and the label comes back.
  • The queue by reading: _inlineRenamePending is only cleared by the newest task (the writes.get(sessionId) !== task identity check), so it goes away on success, failure, a deleted session and a throw, and a stale name can't prefill a later editor. The toast now only comes from the queue, so there's no double toast while the editor is open.
  • Tests: test/inline-rename.test.ts 27/27 in two runs. Session auto-name, the name routes, the tab-layout and i18n files: 128/128. The rail and sidebar static tests: 40/40, and their browser suites: 8/8.
  • The new tests fail without their fixes. Reverting the pending prefill, the toast move or all of item 3 each fails exactly its own test. Reverting the three item 3 layers one at a time still passes, since each layer covers the case by itself, which is fine.
  • The per-layout inline min-width lines up with the selectors (4rem for the rail and the sidebar, sidebar-rich included since it keeps data-session-list="sidebar", and 0 for the header strip), and the stylesheet no longer needs !important.

One optional nit: reopen over a rename in flight and confirm it unchanged, and the label shows the old server name ("Old") until the held PUT lands (src/web/public/session-ui.js:3020). Reopening cancels the first editor, which repaints the label from session.name, so the new editor's originalChildren read "Old", and fullName === shownName restores exactly those. The server ends correct; on a slow link it just looks like the rename was lost for a moment. Painting shownName instead of originalChildren when the two differ would fix it. Not blocking.

Ready to merge after #525.

@Ark0N
Ark0N marked this pull request as ready for review October 5, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants